3.1 View 基础知识

3.1.1 什么是 View

View 是 Android 中所有控件的基类,View 是一种界面层的控件的一种抽象,它代表了一个控件(如:Button、TextView)。除了 View 还有 ViewGroup,可以理解为控件组,它内部包含了许多个控件,即一组 View。在 Android 设计中,ViewGroup 也继承了 View,这就意味 View 本身就可以是单个控件也可以是多个控件组成的一组控件,通过这种关系就形成了 View 树,这和 Web 前端中的 DOM 树的概念是相识的。

3.1.2 View 的位置参数

View 的位置主要由它的四个定点来决定,分别对应于 View 的四个属性:top、left、right、bottom。在 Android 中,x 轴和 y 轴的正方向分别为右和下。

View的位置坐标和父容器的关系.png
View的位置坐标和父容器的关系.png

根据 View 的宽高和坐标的关系,得出:

width = right - left
height = bottom - top

解释部分参数:

// View 的四个属性
left = getLeft();
right = getRight();
top = getTop();
bottom = getBottom();

// x、y 是 View 左上角的坐标
x = getX();
y = getY();

// View 左上角相对于父容器的偏移量
translationX = getTranslationX()
translationY = getTranslationY()

// 换算关系
y = top + translationY

3.1.3 MotionEvent 和 TouchSlop

  • MotionEvent

    在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:

    • ACTION_DOWM 手指刚接触屏幕
    • ACTION_MOVE 手指在屏幕上移动
    • ACTION_UP 手指从屏幕上松开的一瞬间

    正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,考虑如下几种形况:

    • 点击屏幕后离开松开,事件序列为 DOWN -> UP
    • 点击屏幕滑动一会儿再松开,事件序列为 DOWN -> MOVE -> …. > MOVE -> UP
  • TouchSlop

    TouchSlop 是系统所能识别出的被认为是滑动的最小距离。当手指在屏幕上滑动,如果两次滑动之间的距离小于这个常量,那么系统就不认为你是在进行滑动操作。

    通过以下方式获取这个常量:

    ViewConfiguration.get(getContext()).getScaledTouchSlop();

3.1.4 VelocityTracker、GestureDetector 和 Scroller

  1. VelocityTracker

    速度追踪,用于追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。速度的计算公式如下:

    速度 = (终点位置 - 起点位置)/ 时间段

    使用方式:

    // 首先在 View 的 onTouchEvent 方法中追踪当前单击事件的速度
    VelocityTracker vt = VelocityTracker.obtain();
    vt.addMovement(event);
    // 计算速度
    vt.computeCurrentVelocity(1000);
    // 获取速度
    getXVelocity();
    getYVelocity()
    // 重置和回收
    vt.clear();
    vt.recycler();
  2. GestureDetector

    手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。如果只是监听滑动相关的,建议自己在 onTouchEvent 中实现,如果想要监听双击这种行为,那么就用 GestureDetecort。

    使用方式:

    GestureDetector mGes = new GestureDetector(this);
    // 接管目标 View 的 onTouchEvent 方法
    boolean consume = mGes.onTouchEvent(event);
    return consume;
  3. Scroller

    弹性滑动对象,用于实现View的弹性滑动。Scroller本身无法让View弹性滑动,它需要和 View 的computeScroll 方法配合使用才能共同完成这个功能。

    使用方式:

    Scroller mScroller = new Scroller(getContext());

    // 缓慢滑动到指定位置,1000 ms
    private void smoothScrollTo(int destX, int destY) {
    int scrollX = getScrollX();
    int delta = destX - scrollX;
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();
    }

    @Override
    public void computeScroll() {
    if (scroller.computeScrollOffset()) {
    scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
    postInvalidate();
    }
    }

3.2 View 的滑动

通过三种方式可以实现 View 的滑动

  • 第一种是通过 View 本身提供的 scrollTo/scrollBy 方法来实现滑动
  • 第二种是通过动画给 View 施加平移效果来实现滑动
  • 通过改变 View 的 LayoutParams 使得 View 重新布局从而实现滑动

3.2.1 使用 scrollTo/scrollBy

scrollTo 和 scrollBy 方法只能改变 view 内容的位置而不能改变 view 在布局中的位置。 scrollBy 是基于当前位置的相对滑动,而 scrollTo 是基于所传参数的绝对滑动。通过 View 的 getScrollX 和 getScrollY 方法可以得到滑动的距离。

3.2.2 使用动画

使用动画来移动 View 主要是操作view的 translationX 和 translationY 属性,既可以使用传统的 View 动画,也可以使用属性动画。

3.2.3 改变布局参数

通过改变 LayoutParams 的方式去实现 View 的滑动是一种灵活的方法。

3.2.4 各种滑动方式的对比

  • scrollTo/scrollBy:操作简单,适合对 View 内容的滑动
  • 动画:操作简单,主要适用于没有交互的 View 和实现复杂的动画效果
  • 改变布局参数:操作稍微复杂,适用于有交互的 View

3.4 View 的事件分发机制

3.4.1 事件分发机制的三个重要方法

  • public boolean dispatchTouchEvent(MotionEvent ev)

用来进行事件的分发。如果事件能够传递给当前的 View,那么此方法一定会被调用,返回结果受当前 View 的onTouchEvent 和下级 View 的 dispatchTouchEvent 方法的影响,表示是否消耗当前事件。

  • public boolean onInterceptTouchEvent(MotionEvent event)

在上述方法内部调用,用来判断是否拦截某个事件,如果当前 View 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

  • public boolean onTouchEvent(MotionEvent event)

在 dispatchTouchEvent 方法中调用,用来处理点击事件,返回结果表示是否消耗当前的事件,如果不消耗,则在同一个事件序列中,当前 View 无法再次接受到事件。

这三个方法的关系可以用如下伪代码表示:

public boolean dispatchTouchEvent(MotionEvent event)
{
boolean consume = false;
if(onInterceptTouchEvent(ev))
{
consume = onTouchEvent(ev);
}
else
{
consume = child.dispatchTouchEvent(ev);
}
return consume;
}

我们可以大致了解点击事件的传递规则:对于一个根 ViewGroup 来说,点击事件产生后,首先会传递给它,这时它的 dispatchTouchEvent 会被调用,如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示它要拦截当前事件,接着事件就会交给这个 ViewGroup 处理,即它的 onTouchEvent 方法就会被调用;如果这个 ViewGroup 的 onInterceptTouchEvent 方法返回 false 就表示它不拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的 dispatchTouchEvent 方法就会被调用,如此反复直到事件被最终处理。

OnTouchListener 的优先级比 onTouchEvent 要高

如果给一个 View 设置了 OnTouchListener,那么 OnTouchListener 中的 onTouch 方法会被回调。这时事件如何处理还要看 onTouch 的返回值,如果返回 false,那么当前 View 的 onTouchEvent 方法会被调用;如果返回 true,那么 onTouchEvent 方法将不会被调用。 在 onTouchEvent 方法中,如果当前 View 设置了 OnClickListener,那么它的 onClick 方法会被调用,所以 OnClickListener 的优先级最低。

当点击一个事件产生后,它的传递过程遵循如顺序,Activity -> Window -> View

如果一个 View 的 onTouchEvent 方法返回 false,那么它的父容器的 onTouchEvent 方法将会被调用,依次类推,如果所有的元素都不处理这个事件,那么这个事件将会最终传递给 Activity 处理(调用 Activity 的onTouchEvent 方法)

关于事件传递的机制,给出一些结论:

  • 同一个事件序列是以 down 事件开始,中间含有数量不定的 move 事件,最终以 up 事件结束
  • 正常情况下,一个事件序列只能被一个 View 拦截且消耗。一旦一个元素拦截了某次事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个View同时处理,但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过 onTouchEvent 强行传递给其他 View 处理
  • 某个 View 一旦开始处理事件,如果它不消耗 ACTION_DOWN 事件,那么同一事件序列的其他事情都不会再交给它来处理,并且事件将重新交给它的父容器去处理(调用父容器的 onTouchEvent 方法);如果它消耗 ACTION_DOWN 事件,但是不消耗其他类型事件,那么这个点击事件会消失,父容器的 onTouchEvent 方法不会被调用,当前 View 依然可以收到后续的事件,但是这些事件最后都会传递给 Activity 处理。
  • ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的 onInterceptTouchEvent 方法默认返回false,View 没有 onInterceptTouchEvent 方法,一旦有点击事件传递给它,那么它的 onTouchEvent 方法就会调用。
  • View 的 onTouchEvent 默认都会消耗事件(返回true),除非它是不可点击的(clickable 和 longClickable 同时为 false)。View 的 longClickable 属性默认都为 false,clickable 要分情况,比如 Button 的 clickable 属性默认为 true,而 TextView 的 clickable 属性默认为 false。
  • View 的 enable 属性不影响 onTouchEvent 的默认返回值,哪怕一个 View 是 disable 状态的,只要它的 clickable 或者 longClickable 有一个为 true,那么它的 onTouchEvent 就返回 true
  • 事件传递过程总是先传递给父元素,然后再由父元素分发给子 View,通过requestDisallowInterceptTouchEvent 方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN 事件除外,即当面对 ACTION_DOWN 事件时,ViewGroup 总是会调用自己的onInterceptTouchEvent 方法来询问自己是否要拦截事件。

3.5 View 的滑动冲突

3.5.1 常见的滑动冲突场景

  • 外部滑动方向与内部滑动方向不一致,比如 ViewPager 中包含 ListView
  • 外部滑动方向与内部滑动方向一致
  • 上面两种情况的嵌套

3.5.2 滑动冲突的处理规则

可以根据滑动距离和水平方向形成的夹角;或者根据水平和竖直方向滑动的距离差;或者两个方向上的速度差等。

3.5.3 滑动冲突的解决方式

  • 外部拦截法

点击事件都经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,该方法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可,伪代码如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (父容器需要拦截当前点击事件的条件,例如:Math.abs(deltaX) > Math.abs(deltaY)){
intercepted = true;
} else{
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}

mLastXIntercept = x;
mLastYIntercept = y;

return intercepted;
}
  • 内部拦截法

父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就由父容器进行处理,这种方法和 Android 中的事件分发机制不一样,需要配合 requestDisallowInterceptTouchEvent 方法才能正常工作。

public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();

switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {]
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (当前view需要拦截当前点击事件的条件,例如:Math.abs(deltaX) > Math.abs(deltaY)){
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}

mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}